useCallback と useRef を使用して不要な更新を防ぐ
スライドが進んだこと 以外 による再レンダリングでも incrementSlideIndex 関数が新しく生成される
以下のテストを実行すると失敗し、この問題が確認できる
code:Carousel.test.tsx
it("does not reset the auto-advance timer on re-render", () => {
const autoAdvanceInterval = 5_000;
const { rerender } = render(
<Carousel slides={slides} autoAdvanceInterval={autoAdvanceInterval} />,
);
const img = screen.getByRole("img");
expect(img).toHaveAttribute("src", slides0.imgUrl); act(() => {
vi.advanceTimersByTime(autoAdvanceInterval - 1);
});
expect(img).toHaveAttribute("src", slides0.imgUrl); rerender(
<Carousel slides={slides} autoAdvanceInterval={autoAdvanceInterval} />,
);
act(() => {
vi.advanceTimersByTime(1);
});
expect(img).toHaveAttribute("src", slides1.imgUrl); });
問題発生フロー
1. Carousel が再レンダリングされると useSlideIndex が呼び出される
2. 新しい incrementSlideIndex 関数が生成され、useTimeout に渡される
3. React は依存関係が更新されたと認識し、useEffect 内で return している関数を呼び出し、clearTimeout を実行する 4. setTimeout を実行し、新しいタイマーが生成される
解決策
親コンポーネントが同じ Props を渡しても、Carousel が再レンダリングされないようにできる しかし、defaultImgHeight や DefaultImgComponent など別の Props を変更すると、再レンダリングされる
useEffect と同様に 2 つの引数を取る
1 つ目が関数、2 つ目が依存関係の配列
関数を実行する代わりにその関数を返し、依存関係が変わらない限り関数を返す
code:useSlideIndex.tsx
const incrementSlideIndex = useCallback(() => {
if (!slides) return;
setSlideIndexState(increment(slides.length));
onSlideIndexChange?.(increment(slides.length)(slideIndex));
依存関係に起因するバグ
上記の実装では、親コンポーネントが slides や onSlideIndexChange の値を再レンダリング間でキャッシュしなければ、問題がまた発生する
以下のテストを実行すると失敗し、この問題が確認できる
code:Carousel.test.tsx
it("does not reset the timer on irrelevant prop changes", () => {
const autoAdvanceInterval = 5_000;
const CarouselParent = () => (
<Carousel
onSlideIndexChange={vi.fn()} // 新しい関数が生成される
autoAdvanceInterval={autoAdvanceInterval}
/>
);
const { rerender } = render(<CarouselParent />);
const img = screen.getByRole("img");
expect(img).toHaveAttribute("src", slides0.imgUrl); act(() => {
vi.advanceTimersByTime(autoAdvanceInterval - 1);
});
expect(img).toHaveAttribute("src", slides0.imgUrl); rerender(<CarouselParent />);
act(() => {
vi.advanceTimersByTime(1);
});
expect(img).toHaveAttribute("src", slides1.imgUrl); });
CarouselParent がレンダリングされるたびに、slides は新しい配列として、onSlideIndexChange は新しい関数として生成される
これにより、useCallback はそのたびに新しい関数を生成する
解決策
slides と onSlideIndexChange の依存関係がどのように利用されているか考える
code:useSlideIndex.tsx
const incrementSlideIndex = useCallback(() => {
if (!slides) return;
setSlideIndexState(increment(slides.length));
onSlideIndexChange?.(increment(slides.length)(slideIndex));
slides
スライドインデックスの境界を判定するためだけに利用
重要なのは slides.length
onSlideIndexChange
最新のものを呼び出したいが、onSlideIndexChange が変更されることによる副作用は起こしたくない
onSlideIndexChange をキャッシュして、incrementSlideIndex の中で使用することで対応できる
それぞれの依存関係について対処する
slides
code:useSlideIndex.tsx
const incrementSlideIndex = useCallback(() => {
if (!slides?.length) return;
setSlideIndexState(increment(slides.length));
onSlideIndexChange?.(increment(slides.length)(slideIndex));
onSlideIndexChange
code:useSlideIndex.tsx
const onSlideIndexChangeRef = useRef(onSlideIndexChange);
onSlideIndexChangeRef.current = onSlideIndexChange;
const incrementSlideIndex = useCallback(() => {
if (!slides?.length) return;
setSlideIndexState(increment(slides.length));
onSlideIndexChangeRef.current?.(increment(slides.length)(slideIndex));
onSlideIndexChangeRef は useRef によって常に同じオブジェクトが返されるので、依存関係に入れる必要はない